Repository navigation
feat(mobile,trpc,auth): gate mobile behind a paid plan - #6411
Conversation
Mobile is a Pro surface: unpaid sessions land on a paywall screen (org switcher for members whose active org is unpaid, refresh, support, sign out, and account deletion so 5.1.1(v) survives the gate). The gate is one wall — a RootLayout tier client-side plus one protectedProcedure middleware keyed on the new x-superset-client header that mobile now sends (which also lights up the dormant per-client API telemetry). Credential sign-IN is enabled in production for the App Store review demo account (sign-up stays dev/preview-only), reachable via a quiet sign-in link; db:seed-review provisions the account idempotently.
📝 WalkthroughWalkthroughThis change adds mobile paywall routing and controls, email/password sign-in, account deletion handling, client version headers, server-side mobile plan enforcement, and a review-account seeding script. ChangesMobile authentication and paywall
Estimated code review effort: 4 (Complex) | ~45 minutes Mergeability Score: 🟠 High · up to The mobile plan gate can currently grant access to an unpaid organization or deny access to a paid one, while the review-account seed can attach Pro access to the wrong organization. These authorization and review-readiness issues, along with exposed password entry and logged review email data, make the PR unsafe to merge until corrected. Sequence Diagram(s)sequenceDiagram
participant RootLayout
participant PaywallScreen
participant MobileTRPCClient
participant tRPCMiddleware
participant AuthAPI
RootLayout->>PaywallScreen: route unpaid authenticated session
PaywallScreen->>MobileTRPCClient: request account or organization action
MobileTRPCClient->>tRPCMiddleware: send mobile request with client version
tRPCMiddleware->>AuthAPI: execute allowlisted procedure
AuthAPI-->>PaywallScreen: return action result
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
apps/mobile/screens/paywall/PaywallScreen.tsx (1)
45-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the shared deletion grace-period constant.
Line 45 duplicates the
30day retention period. ImportACCOUNT_DELETION_GRACE_DAYS, asAccountSettingsScreendoes, so policy updates keep both deletion notices consistent.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/screens/paywall/PaywallScreen.tsx` at line 45, Update the deletion notice in PaywallScreen to interpolate the shared ACCOUNT_DELETION_GRACE_DAYS constant instead of hardcoding 30, importing and using it consistently with AccountSettingsScreen.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/mobile/screens/`(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx:
- Around line 25-29: Update AlertPromptModule and PromptOptions to support an
isSecureTextEntry setting on the created UITextField, then enable that setting
in the password prompt within EmailSignInLink. Preserve existing prompt behavior
for callers that do not request secure input.
In `@packages/auth/src/seed-review-account.ts`:
- Around line 59-92: The membership lookup in the review-account seeding flow
must target the deterministic review organization instead of any organization
owned by the user. Use the `${user.id.slice(0, 8)}-review` slug to find or
create that organization, query membership for its organization ID, and use that
same ID when checking and inserting the active Pro subscription.
- Line 32: Update the review-account creation logging in seedReviewAccount to
omit the APP_REVIEW_EMAIL value from both creation and related log messages. Log
only a generic event or status while preserving the existing success and failure
behavior.
In `@packages/trpc/src/trpc.ts`:
- Around line 135-146: Update the middleware around the plan check to resolve
the requested organization and establish activeOrganizationId before evaluating
payment access. Load the plan for that resolved organization rather than relying
on ctx.session.session.plan, then enforce the existing
MOBILE_PAYWALL_ALLOWED_PROCEDURES restriction using the resolved plan for both
paid and unpaid session cases.
---
Nitpick comments:
In `@apps/mobile/screens/paywall/PaywallScreen.tsx`:
- Line 45: Update the deletion notice in PaywallScreen to interpolate the shared
ACCOUNT_DELETION_GRACE_DAYS constant instead of hardcoding 30, importing and
using it consistently with AccountSettingsScreen.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9cb07c3c-d23e-41a6-b74e-5e91a4efe330
📒 Files selected for processing (15)
apps/mobile/app/paywall.tsxapps/mobile/hooks/useDeleteAccount/index.tsapps/mobile/hooks/useDeleteAccount/useDeleteAccount.tsapps/mobile/lib/trpc/client.tsapps/mobile/screens/(auth)/sign-in/SignInScreen.tsxapps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsxapps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/index.tsapps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsxapps/mobile/screens/RootLayout/RootLayout.tsxapps/mobile/screens/paywall/PaywallScreen.tsxapps/mobile/screens/paywall/index.tspackage.jsonpackages/auth/src/seed-review-account.tspackages/auth/src/server.tspackages/trpc/src/trpc.ts
| const password = await prompt({ | ||
| title: "Sign in with email", | ||
| message: `Password for ${email}`, | ||
| confirmText: "Sign in", | ||
| }); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -t f . apps/mobile/modules/alert-prompt | sort
rg -n -C 5 'PromptOptions|secure|password|prompt\(' apps/mobile/modules/alert-promptRepository: superset-sh/superset
Length of output: 3467
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- native prompt implementation ---'
cat -n apps/mobile/modules/alert-prompt/ios/AlertPromptModule.swift
printf '%s\n' '--- TypeScript API ---'
cat -n apps/mobile/modules/alert-prompt/src/index.ts
printf '%s\n' '--- component ---'
cat -n 'apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx'
printf '%s\n' '--- prompt usages ---'
rg -n -C 6 'prompt\(' apps/mobile --glob '*.{ts,tsx}'Repository: superset-sh/superset
Length of output: 20811
Mask the password input. AlertPromptModule creates a plain UITextField, and PromptOptions has no secure-input option. Add isSecureTextEntry support and enable it for the password prompt at EmailSignInLink.tsx:25.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/mobile/screens/`(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx
around lines 25 - 29, Update AlertPromptModule and PromptOptions to support an
isSecureTextEntry setting on the created UITextField, then enable that setting
in the password prompt within EmailSignInLink. Preserve existing prompt behavior
for callers that do not request secure input.
| .insert(users) | ||
| .values({ name: "App Review", email, emailVerified: true }) | ||
| .returning(); | ||
| console.log(`Created review user: ${email}`); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Do not write the review email address to logs.
APP_REVIEW_EMAIL is personal data. These messages can persist in shared CI and deployment logs. Log the event state without the email address.
Proposed fix
- console.log(`Created review user: ${email}`);
+ console.log("Created review user");
...
- console.log(`Review account ready: ${email} (onboarded, pro)`);
+ console.log("Review account ready (onboarded, pro)");Also applies to: 94-94
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/auth/src/seed-review-account.ts` at line 32, Update the
review-account creation logging in seedReviewAccount to omit the
APP_REVIEW_EMAIL value from both creation and related log messages. Log only a
generic event or status while preserving the existing success and failure
behavior.
Source: Linters/SAST tools
| let membership = await db.query.members.findFirst({ | ||
| where: eq(members.userId, user.id), | ||
| }); | ||
| if (!membership) { | ||
| // Raw user inserts bypass the user-create database hook, so the | ||
| // personal org has to be created explicitly. | ||
| await auth.api.createOrganization({ | ||
| body: { | ||
| name: "App Review Team", | ||
| slug: `${user.id.slice(0, 8)}-review`, | ||
| userId: user.id, | ||
| }, | ||
| }); | ||
| membership = await db.query.members.findFirst({ | ||
| where: eq(members.userId, user.id), | ||
| }); | ||
| } | ||
| if (!membership) throw new Error("review user has no organization"); | ||
|
|
||
| const activeSubscription = await db.query.subscriptions.findFirst({ | ||
| where: and( | ||
| eq(subscriptions.referenceId, membership.organizationId), | ||
| eq(subscriptions.status, "active"), | ||
| ), | ||
| }); | ||
| if (!activeSubscription) { | ||
| await db.insert(subscriptions).values({ | ||
| plan: "pro", | ||
| referenceId: membership.organizationId, | ||
| status: "active", | ||
| billingInterval: "monthly", | ||
| seats: 1, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Bind the subscription to the review organization.
findFirst can select any organization when the review user has multiple memberships. The script can then insert a Pro subscription for an unrelated organization. That organization receives paid mobile access because packages/auth/src/server.ts derives session.plan from its active subscription.
Find or create the review organization by its deterministic review slug. Query the membership for that organization only. Insert the subscription with that organization ID.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/auth/src/seed-review-account.ts` around lines 59 - 92, The
membership lookup in the review-account seeding flow must target the
deterministic review organization instead of any organization owned by the user.
Use the `${user.id.slice(0, 8)}-review` slug to find or create that
organization, query membership for its organization ID, and use that same ID
when checking and inserting the active Pro subscription.
| .use(async ({ ctx, path, next }) => { | ||
| if ( | ||
| ctx.client?.product === "mobile" && | ||
| !ctx.session.session.plan && | ||
| !MOBILE_PAYWALL_ALLOWED_PROCEDURES.has(path) | ||
| ) { | ||
| throw new TRPCError({ | ||
| code: "FORBIDDEN", | ||
| message: "Superset Mobile requires a paid plan.", | ||
| }); | ||
| } | ||
| return next(); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Evaluate the plan after organization selection.
A paid session can send ORGANIZATION_HEADER for an unpaid organization. This middleware allows the request from the paid session plan. The following middleware then sets that unpaid organization as activeOrganizationId.
An unpaid session also cannot access a paid organization selected by the header. Resolve the requested organization first. Then load and enforce the plan for the resolved activeOrganizationId.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/trpc/src/trpc.ts` around lines 135 - 146, Update the middleware
around the plan check to resolve the requested organization and establish
activeOrganizationId before evaluating payment access. Load the plan for that
resolved organization rather than relying on ctx.session.session.plan, then
enforce the existing MOBILE_PAYWALL_ALLOWED_PROCEDURES restriction using the
resolved plan for both paid and unpaid session cases.
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
…6413) * Revert "feat(mobile,trpc,auth): gate mobile behind a paid plan (#6411)" This reverts commit 7b1844a. * revert(mobile,trpc,auth): back out the pro-plan gate pending testing Reverts #6411 (paywall, prod credential sign-in, seed script, client header) and restores the ungated review posture in store.config.js (demoRequired false, SIWA-instant-account notes). The dynamic config and version pin from #6412 stay. The gate lives on in the mobile-paywall branch for iteration.
Mobile launches as a Pro perk (user decision — limits users while we iterate, avoids clawing back free access later). Externally-billed subscription gating with no IAP, per guideline 3.1.3(b) multiplatform services — the Netflix/Slack pattern.
The gate is one wall, twice
Stack.Protectedtier in mobile's RootLayout — signed-in + not-pending-deletion + no paid plan →paywallscreen.planalready rides the session viacustomSession, so no extra query.protectedProceduremiddleware —mobile client + no plan on the active org → FORBIDDEN, allowlisting only account self-management (user.me/deleteAccount/reactivateAccount/myOrganizations). Keyed on thex-superset-clientheader, which mobile now actually sends (the header parsing existed but no client ever adopted it — this also turns on the per-client API telemetry for mobile). Other clients are never gated.No per-feature plan checks anywhere — nothing to remember as the product grows.
The paywall screen
Steering-safe copy ("Manage your plan from the Superset desktop app" — no external purchase link, valid on every storefront), plus: org switcher (a member whose active org is their free personal org but who belongs to a paid org can switch right there — otherwise real customers would hit a dead wall), Refresh, Contact support, Log out, and Delete account (grace-period flow) so guideline 5.1.1(v) survives the gate.
App Store review access
emailAndPassword.enabledalways;disableSignUpoutside dev/preview so nobody can register)bun run db:seed-review(APP_REVIEW_EMAIL/APP_REVIEW_PASSWORD) idempotently provisions the demo account: credential login, onboarded, Pro-subscribed personal org (subscription row only, no Stripe charge)store.config.jsonreview-notes/demoRequired update rides chore(mobile,marketing): app store submission metadata and privacy policy #6408; the credentials themselves go into ASC's App Review sign-in fields by hand, never the repoKnown residuals
The relay/host path is not plan-gated (client gate covers the UX; a determined free user scripting the relay directly isn't a launch concern). Desktop/web remain ungated by design.
Testing
Summary by cubic
Gate Superset Mobile behind a paid plan and add a paywall screen. Previously any signed-in user could use mobile; now only sessions with a paid plan on the active org can proceed, and unpaid sessions land on the paywall. Desktop/web behavior is unchanged.
Stack.Protectedtier keyed onsession.plan; unpaid sessions route topaywall(no extra query; plan is in the session).protectedProcedureforbids mobile without a plan exceptuser.me,user.deleteAccount,user.reactivateAccount, anduser.myOrganizations. Gating is keyed by thex-superset-clientheader, which the mobile client now sends asmobile/<version>(also enables per-client API telemetry).Rollout
Written for commit caf83ad. Summary will update on new commits.
Summary by CodeRabbit